package edu.ua.bmr.android;

import java.io.IOException;
import java.io.UnsupportedEncodingException;
import java.net.MalformedURLException;
import java.net.URL;
import java.net.URLEncoder;
import java.util.ArrayList;
import java.util.HashMap;
import javax.xml.parsers.SAXParser;
import javax.xml.parsers.SAXParserFactory;
import org.xml.sax.SAXException;

import android.os.Bundle;
import android.util.Log;
import edu.ua.bmr.claxml.ClaXMLHandler;
import edu.ua.bmr.claxml.ClaXMLSet;

/**
 * This object keeps track of the user session. It also abstracts the communication 
 * practices of all the other objects in this application.
 * 
 * All caching should be handled by this object.
 * 
 * A note: addClass() and dropClass() are both written to handle just one
 * result while the backend is technically able to handle several adds
 * or drops at a time. 
 * 
 * @author OEP
 *
 */
public class Session {
	/** Debugging tag */
	public static final String TAG = "Session";
	
	/** The URL for our MyBama API */
	public static final String API_URL = "http://students.mint.ua.edu/~pmkilgo/bmr/";
	
	/** Various state names for this object */
	public static enum State { INITIAL, LOGGED_IN };
	
	/** The single-instance instantiation of this object */
	private static Session mStaticSession = new Session();
	
	/** The CWID of the student for this session */
	private String mCWID;
	
	/** The session ID for this session */
	private String mSessionID;
	
	/** The current working semester ID for this session */
	private String mWorkingSemester;
	
	/** This String gets set once, corresponding to what the server thinks the current semester is */
	private String mCurrentSemester;
	
	/** The current state of this object */
	private State mState = Session.State.INITIAL;
	
	/** The username for this session */
	private String mUsername;
	
	/** A cache of the semester's available for this user */
	private ArrayList<HashMap<String,String>> mSemesterCache = null;
	
	/** A cache of the user's classes for this semester */
	private ArrayList<HashMap<String,String>> mClasslistCache = null;

	/** The name of the current semester for this session */
	private String mSemesterName;
	
	/** Can we drop classes for this semester? */
	private boolean mDroppable;
	
	/** Can we add classes for this semester? */
	private boolean mAddable;
	
	/** Are we confirmed for this semester? */
	private boolean mConfirmed;
	
	/** 
	 * This object cannot be instantiated publicly.
	 */
	private Session() {
		
	}
	
	/**
	 * Get the instance of the Session object.
	 * @return instance of 'Session'
	 */
	public static Session getInstance() {
		return mStaticSession;
	}
	
	/**
	 * Given a username/password, attempt to move this object into a "LOGGED_IN" state.
	 * @param username
	 * @param password
	 * @return boolean indicating success or failure.
	 * @throws Exception if there is a fundamental communication error.
	 */
	public boolean login(String username, String password) throws BMRException {
		if(mState == Session.State.LOGGED_IN)
			logout();
		
		// Form the URL associated with this request and fetch it.
		String url = Session.buildURL(null, username + ":" + password, null, "auth");
		ClaXMLSet set = Session.fetch(url);
		
		// There can only be one!
		if(set.getSize() != 1) {
			throw new BMRException(R.string.invalid_server_response, "Number of results is not equal to one.");
		}
		
		// By convention, the server should respond with response type "response".
		String type = set.getResponseType();
		if(type.compareTo("response") != 0) {
			throw new
				BMRException(R.string.invalid_server_response, "Expected response type 'response' but received'" + type +"'");
		}
		
		// Grab that first row.
		HashMap<String,String> row = set.getObject(0);
		
		// See if we win the grand prize. If we do, then the server should have returned
		// us some useful information.
		String result = row.get("result");
		if(result.compareTo("true") == 0) {
			mCWID = row.get("cwid");
			mSessionID = row.get("session");
			mState = State.LOGGED_IN;
			mUsername = username;
			
			// This method requires being logged in.
			setSemesterId(row.get("current-sid"));
			mCurrentSemester = mWorkingSemester;
			return true;
		}
		else
			// Go cry about it loser kid.
			return false;
	}
	
	/**
	 * No matter the current state of the object, return to the state
	 * where we are not logged in.
	 */
	public void logout() {
		Log.i(TAG, "Destroying session");
		mCWID = "";
		mSessionID = "";
		mWorkingSemester = "";
		mClasslistCache = null;
		mSemesterCache = null;
		mUsername = null;
		mState = State.INITIAL;
	}
	
	/**
	 * Given the user's CWID and the current SID we are working with, fetch the user's
	 * current classes for the current working semester.
	 * @return A data structure convenient for SimpleAdapter.
	 * @throws Exception
	 */
	public ArrayList< HashMap<String,String> > fetchCurrentClasses() throws BMRException {
		requireLoggedIn();
		
		if(mClasslistCache == null) {
			String url = Session.buildURL(mSessionID, "sid:" + mWorkingSemester, null, "classlist");
			ClaXMLSet result = Session.fetchGenericList(url, "class");
		
			// We think the data is good. Return it.
			mClasslistCache = result.getData();
		}
		
		return mClasslistCache;
	}
	
	public boolean isCurrentClassListCached() {
		return mClasslistCache != null;
	}
	
	/**
	 * Given the current SID we are working with, fetch a list
	 * of classes which match the search term the user gave to us.
	 * @return A data structure convenient for SimpleAdapter.
	 * @throws BMRException
	 */
	public ArrayList< HashMap<String,String> > searchClasses(String term) throws BMRException {
		requireLoggedIn();
		String url = Session.buildURL(mSessionID, term + ",sid:" + mWorkingSemester, "class", "search");
		ClaXMLSet set = Session.fetchGenericList(url, "class");
		return set.getData();
	}
	
	/**
	 * If not cached, fetch all the semesters for which this user is currently registered.
	 * @return A list of the Semesters available to the logged in user
	 * @throws BMRException
	 */
	public ArrayList< HashMap<String, String> > fetchSemesterList() throws BMRException {
		requireLoggedIn();
		
		if(mSemesterCache == null) {
			String url = Session.buildURL(mSessionID, null, null, "semesterlist");
			ClaXMLSet set = Session.fetchGenericList(url, "semester");
			mSemesterCache = set.getData();
		}
	
		return mSemesterCache;
	}
	
	/**
	 * Attempt to add a given class.
	 * @param crn, the CRN of the class to be added
	 * @return the parsed response of the server
	 * @throws BMRException
	 */
	public Bundle addClass(String crn) throws BMRException {
		requireLoggedIn();
		String url = Session.buildURL(mSessionID, crn, null, "register");
		ClaXMLSet set = Session.fetchGenericList(url, "attempt");
		
		// Grab the data and get the first result.
		ArrayList<HashMap<String,String>> list = set.getData();
		HashMap<String,String> map = list.get(0);
		
		// Extract the data.
		boolean result = map.get(BMR.KEYWORD_RESULT).compareTo("0") != 0;
		String msg = map.get(BMR.KEYWORD_MSG);
		
		// Put the data into a bundle
		Bundle b = new Bundle();
		b.putBoolean(BMR.KEYWORD_RESULT, result);
		b.putString(BMR.KEYWORD_MSG, msg);
		
		return b;
	}
	
	/**
	 * Attempt to drop a given class.
	 * @param crn, the CRN of the class to be dropped
	 * @return the parsed response of the server
	 * @throws BMRException
	 */
	public Bundle dropClass(String crn) throws BMRException {
		requireLoggedIn();
		String url = Session.buildURL(mSessionID, crn, null, "drop");
		ClaXMLSet set = Session.fetchGenericList(url, "attempt");
		
		// Grab the data and get the first result.
		ArrayList<HashMap<String,String>> list = set.getData();
		HashMap<String,String> map = list.get(0);
		
		// Extract the data.
		boolean result = map.get(BMR.KEYWORD_RESULT).compareTo("0") != 0;
		String msg = map.get(BMR.KEYWORD_MSG);
		
		// Put the data into a bundle
		Bundle b = new Bundle();
		b.putBoolean(BMR.KEYWORD_RESULT, result);
		b.putString(BMR.KEYWORD_MSG, msg);
		
		return b;
	}
	
	/**
	 * Try and confirm the current semester.
	 * @return true if success, false if failed
	 * @throws BMRException
	 */
	public Bundle confirmSemester() throws BMRException {
		// Gotta be logged in, chump.
		requireLoggedIn();
		
		// Fetch the data set.
		String url = Session.buildURL(mSessionID, mWorkingSemester, null, "confirm");
		ClaXMLSet set = Session.fetchGenericList(url, "attempt");
		
		// Hammer down the data set into one row.
		ArrayList<HashMap<String,String>> data = set.getData();
		HashMap<String,String> map = data.get(0);
		
		// Prepare the Bundle to shoot out
		Bundle b = new Bundle();
		
		// Big long thing that checks if result is != 0.
		// Updates mConfirmed accordingly.
		boolean result = (mConfirmed = map.get(BMR.KEYWORD_RESULT).compareTo("0") != 0);
		b.putBoolean(BMR.KEYWORD_RESULT, result);
		
		// Shoot out the message the server shot us.
		String msg = map.get(BMR.KEYWORD_MSG);
		b.putString(BMR.KEYWORD_MSG, msg);
		
		return b;
	}
	
	/**
	 * Since we externally check if a drop or add is successful, we provide this method
	 * to force the recache of the classlist.
	 */
	public void forceClasslistRecache() {
		mClasslistCache = null;
	}
	
	/**
	 * Get the "pretty" name of the current semester.
	 * @return String containing the name of the semester.
	 */
	public String getSemesterName() {
		return getSemesterName(mWorkingSemester);
	}
	
	/**
	 * Find the semester name for the given ID.
	 * @param id
	 * @return pretty name of the semester
	 */
	public String getSemesterName(String id) {
		for(HashMap<String,String> map : mSemesterCache) {
			if(map.get(BMR.TABLE_SEMESTER_ID).compareTo(id) == 0)
				return map.get(BMR.TABLE_SEMESTER_NAME);
		}
		
		throw new IllegalArgumentException("Did not find semester with id '" + id + "'");
	}

	/**
	 * Get the Semester ID name of the working semester.
	 * @return String containing the name of the semester.
	 */
	public String getSemesterId() {
		return mWorkingSemester;
	}
	
	/**
	 * Attempt to fetch a ClaXMLSet that contains a list of generic objects. Throws exceptions
	 * if the data retrieved appears to be bad.
	 * @param url, the URL to fetch
	 * @return the ClaXMLSet generated by the request
	 * @throws BMRException on bad data
	 */
	private static ClaXMLSet fetchGenericList(String url, String objectType) throws BMRException {
		ClaXMLSet result = Session.fetch(url);
		
		// We're expecting lists only...
		if(result.getResponseType().compareTo("list") != 0) {
			String response = result.getResponseType();
			throw new BMRException(R.string.invalid_server_response, "Expected 'list' but found '" + response + "'");
		}
		
		// We're expecting classes in these lists only...
		if(result.getSize() > 0 && result.getObjectType().compareTo(objectType) != 0) {
			String object = result.getObjectType();
			throw new BMRException(R.string.invalid_server_response, "Expected '" + objectType + "' but found '" + object + "'");
		}
		
		return result;
	}
	
	/**
	 * Reusable code used to fetch a ClaXMLSet associated with specific URL. Also translates
	 * other exceptions into something we can use to display user-friendly results about
	 * what is going on.
	 * @param url
	 * @return
	 * @throws BMRException
	 */
	private static ClaXMLSet fetch(String url) throws BMRException {
		Log.i(TAG, "Fetching: " + url);
		
		ClaXMLHandler h = new ClaXMLHandler();
		SAXParserFactory factory = SAXParserFactory.newInstance();		
		SAXParser saxParser;
		try {
			saxParser = factory.newSAXParser();
		} catch (Exception e) {
			throw new BMRException(R.string.parser_setup_error, e.getMessage());
		}
		
		URL tmp;
		try {
			tmp = new URL(url);
		} catch (MalformedURLException e) {
			throw new BMRException(R.string.invalid_url_request, e.getMessage());
		}
		
		try {
			saxParser.parse(tmp.openStream(), h);
		} catch (SAXException e) {
			throw new BMRException(R.string.parse_error, e.getMessage());
		} catch (IOException e) {
			throw new BMRException(R.string.network_error, e.getMessage());
		}
		
		Session.handleErrors(h.getParsedData());
		
		return h.getParsedData();
	}
	
	/**
	 * Throws an exception if the provided ClaXMLSet is an error.
	 * @param set
	 * @throws Exception
	 */
	private static void handleErrors(ClaXMLSet set) throws BMRException {
		// Firstly, see if the parser thought the ClaXML is bad.
		if(set.getInvalid() == true) {
			throw new BMRException(R.string.invalid_claxml, "Detected an invalid ClaXML document.");
		}
		
		// Check the response type. Make sure it is 'error'.
		String type = set.getResponseType();
		if(type.compareTo("error") != 0) return;
		
		// Grab the first row.
		HashMap<String,String> row = set.getObject(0);
		
		// By convention, we use code to represent the unique error code and
		// msg to indicate the user-friendly message.
		String message = row.get("code") + ": " + row.get("msg");
		
		throw new BMRException(R.string.request_error, message);
	}
	
	/**
	 * Checks the state of this object to see if it is in a "Logged In" state.
	 * @return true if logged in, false if not.
	 */
	public boolean isLoggedIn() {
		return mState == State.LOGGED_IN;
	}
	
	/**
	 * Requires that the state of this object is logged in. Throws an
	 * exception if not logged in.
	 * @throws BMRException
	 */
	public void requireLoggedIn() throws BMRException {
		if(isLoggedIn() == false) throw new BMRException(R.string.not_logged_in, "You must be logged in first.");
	}
	
	/**
	 * Generate a URL matching the request.
	 * @param sessionID, the session ID
	 * @param args, arguments needed for the current action
	 * @param resource, the database we are querying
	 * @param action, the action we wish to perform
	 * @return String representation of the URL.
	 */
	public static String buildURL(String sessionID, String args, String resource, String action) {
		StringBuffer bf = new StringBuffer();
		char thing = '?';
		
		bf.append(Session.API_URL);
		
		// Tack on session ID
		if(sessionID != null) {
			bf.append(thing + "PHPSESSID=" + sessionID);
			thing = '&';
		}
		
		// Tack on ?q={args}
		if(args != null) {
			try {
				bf.append(thing + "q=" + URLEncoder.encode(args, BMR.TEXT_FORMAT));
			} catch (UnsupportedEncodingException e) {
				e.printStackTrace();
			}
			thing = '&';
		}
		
		// Tack on the resource we want...
		if(resource != null) {
			try {
				bf.append(thing + "r=" + URLEncoder.encode(resource, BMR.TEXT_FORMAT));
			} catch (UnsupportedEncodingException e) {
				e.printStackTrace();
			}
			thing = '&';
		}
		
		// Tack on the action we are performing.
		if(action != null) {
			try {
				bf.append(thing + "act=" + URLEncoder.encode(action, BMR.TEXT_FORMAT));
			} catch (UnsupportedEncodingException e) {
				e.printStackTrace();
			}
			thing = '&';
		}
		
		return bf.toString();
	}
	
	/**
	 * Causes the Session object to replace the cached data
	 * with what is actually provided by the server.
	 * @throws BMRException 
	 */
	private void updateSemesterInfo() throws BMRException {
		// Get the (hopefully cached) semesters
		ArrayList<HashMap<String,String>> semesters = fetchSemesterList();
		
		// Iterate until we found our semester
		for(HashMap<String,String> map : semesters) {
			String id = map.get(BMR.TABLE_SEMESTER_ID);
			
			if(mWorkingSemester.compareTo(id) == 0) {
				// We found our semester. Update the info and break from the loop.
				mSemesterName = map.get(BMR.TABLE_SEMESTER_NAME);
				mAddable = map.get(BMR.TABLE_SEMESTER_ADDABLE).compareTo("0") != 0;
				mDroppable = map.get(BMR.TABLE_SEMESTER_DROPPABLE).compareTo("0") != 0;
				mConfirmed = map.get(BMR.KEYWORD_CONFIRMED).compareTo("0") != 0;
				break;
			}
		}
	}

	public void setSemesterId(String id) throws BMRException {
		// Now this would just be plain silly.
		if(mWorkingSemester != null && mWorkingSemester.compareTo(id) == 0) return;
	
		// Classlist cache is now outdated.
		mClasslistCache = null;
		
		mWorkingSemester = id;
		updateSemesterInfo();
	}
	
	public boolean isAddable() {
		return mAddable;
	}
	
	public boolean isDroppable() {
		return mDroppable;
	}
	
	public boolean isConfirmed() {
		return mConfirmed;
	}
	
	public String getUsername() {
		return mUsername;
	}
	
	public boolean isLoggedIn(String user) {
		return isLoggedIn() && mUsername.compareTo(user) == 0;
	}
	
	public boolean haveNewSemester() {
		if(mSemesterCache == null) return false;
		
		// Semesters are ordered in reverse chronological order, so the
		// most recent semester is at position 0. Therefore, if the Semester
		// at position 0 in this list is not the current semester, we have
		// a newer semester.
		
		// Loop through the semester cache and return true if we find our semester
		// before any semesters with the "ADD" flag set to true.
		for(HashMap<String,String> semester : mSemesterCache) {
			String id = semester.get(BMR.TABLE_SEMESTER_ID);
			if(id.compareTo(mCurrentSemester) == 0) return false;
			
			String add = semester.get(BMR.TABLE_SEMESTER_ADDABLE);
			if(add.compareTo("0") != 0) return true;
		}
		
		// Current semester ID not found.
		return false;		
	}
	
	public ArrayList<String> getNewSemesters() {
		ArrayList<String> ids = new ArrayList<String>();
		for(HashMap<String,String> semester : mSemesterCache) {
			// Stop once we find the current semester
			String id = semester.get(BMR.TABLE_SEMESTER_ID);
			if(id.compareTo(mCurrentSemester) == 0) return ids;
			
			// Don't add it if it is not open for adding classes.
			String add = semester.get(BMR.TABLE_SEMESTER_ADDABLE);
			if(add.compareTo("0") == 0) continue;
			
			// Add it to the output
			ids.add(id);
		}
		
		// We didn't find the current semester, but that is fine.
		return ids;
	}
}
